ServiceLoaderDialectResolver.java
package org.codefilarete.stalactite.sql;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.Comparator;
import java.util.Map.Entry;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import org.codefilarete.stalactite.engine.DatabaseVendorSettings;
import org.codefilarete.tool.Nullable;
import org.codefilarete.tool.Strings;
import org.codefilarete.tool.VisibleForTesting;
import org.codefilarete.tool.collection.Iterables;
import org.codefilarete.tool.exception.Exceptions;
/**
* Implementation of {@link DialectResolver} that gets its registered {@link Dialect}s through JVM Service Provider and looks for the most compatible
* one thanks to a compatibility algorithm.
*
* This class will get available dialects and their compatibility as instances of {@link DialectResolverEntry}, themselves declared by JVM Service
* Provider. Hence, it is expected that dialect implementors declare them through META-INF/services/DialectResolver.DialectResolverEntry
* file. Then when {@link #determineDialect(Connection)} is invoked, database metadata are compared to compatibility given by entries: only entries
* whom product name exactly matches database one are kept, then comparing version, the highest dialect among smaller than database one is selected.
* For example, if database is "A wonderful database 3.8", and 3 dialects for "A wonderful database" are present with "3.1", "3.5" and "4.0" versions,
* then the "3.5" will be selected.
*
* Why such algorithm ? because a dialect is expected to benefit from database features, hence its version should be close to the one of the
* database that implements the feature, meaning at least equal but not lower : a "4.0" dialect may not be compatible with a "3.0" database. Therefore,
* only smaller dialect versions are valuable, and among them, we take the closest one to benefit from best features. We also consider that databases
* are retro-compatible so older dialects are still relevant.
*
* @author Guillaume Mary
*/
public class ServiceLoaderDialectResolver implements DialectResolver {
@Override
public Dialect determineDialect(Connection conn) {
DatabaseSignet databaseSignet = DatabaseSignet.fromMetadata(conn);
ServiceLoader<DialectResolverEntry> dialects = ServiceLoader.load(DialectResolverEntry.class);
return determineDialect(dialects, databaseSignet);
}
Dialect determineDialect(Iterable<? extends DialectResolverEntry> dialects, DatabaseSignet databaseSignet) {
Nullable<DialectResolverEntry> matchingDialect = Nullable.nullable(giveMatchingEntry(dialects, databaseSignet));
return matchingDialect.map(DialectResolverEntry::getDialect).getOrThrow(
() -> new IllegalStateException(
"Unable to determine dialect to use for database \""
+ databaseSignet.getProductName()
+ " " + databaseSignet.getMajorVersion()
+ "." + databaseSignet.getMinorVersion()
+ "\" among " + Iterables.collectToList(dialects, o -> "{" + Strings.footPrint(o.getCompatibility(),
DatabaseSignet::toString) + "}")));
}
@Override
public DatabaseVendorSettings determineVendorSettings(Connection conn) {
DatabaseSignet databaseSignet = DatabaseSignet.fromMetadata(conn);
ServiceLoader<DialectResolverEntry> dialects = ServiceLoader.load(DialectResolverEntry.class);
return determineVendorSettings(dialects, databaseSignet);
}
DatabaseVendorSettings determineVendorSettings(Iterable<? extends DialectResolverEntry> dialects, DatabaseSignet databaseSignet) {
DialectResolverEntry matchingDialect = giveMatchingEntry(dialects, databaseSignet);
if (matchingDialect == null) {
throw new IllegalStateException(
"Unable to determine vendor settings to use for database \""
+ databaseSignet.getProductName()
+ " " + databaseSignet.getMajorVersion()
+ "." + databaseSignet.getMinorVersion()
+ "\" among " + Iterables.collectToList(dialects, o -> "{" + Strings.footPrint(o.getCompatibility(),
DatabaseSignet::toString) + "}"));
} else {
return matchingDialect.getVendorSettings();
}
}
@VisibleForTesting
@javax.annotation.Nullable
DialectResolverEntry giveMatchingEntry(Iterable<? extends DialectResolverEntry> dialects, DatabaseSignet databaseSignet) {
// only dialects that exactly matches database product name are kept
Set<DialectResolverEntry> databaseDialects = Iterables.stream(dialects)
.filter(entry -> entry.getCompatibility().getProductName().equals(databaseSignet.getProductName()))
.collect(Collectors.toSet());
if (databaseDialects.isEmpty()) {
// no dialect for database, caller will handle that
return null;
} else {
// sorting entries by compatibility versions to ease selection of the highest among the smaller than database version
// Note: we could have used the stream way of collecting things, but it's a bit less readable
TreeMap<DatabaseSignet, DialectResolverEntry> dialectPerSortedCompatibility = new TreeMap<>(DatabaseSignet.COMPARATOR);
databaseDialects.forEach(dialect -> dialectPerSortedCompatibility.merge(dialect.getCompatibility(), dialect, (c1, c2) -> {
// we use same properties as DatabaseSignet comparator ones since we use a TreeMap based on it
String printableSignet = Strings.footPrint(c1.getCompatibility(), DatabaseSignet::toString);
throw new IllegalStateException("Multiple dialects with same database compatibility found : " + printableSignet);
}));
// we select the highest dialect among the smaller than database version
Entry<DatabaseSignet, DialectResolverEntry> foundEntry = dialectPerSortedCompatibility.floorEntry(databaseSignet);
return foundEntry == null ? null : foundEntry.getValue();
}
}
/**
* Storage for database product and version.
*/
public static class DatabaseSignet {
/**
* Builds a {@link DatabaseSignet} from a connection to create the database signature from its metadata.
* Could be a constructor but would require callers to handle {@link SQLException} which is quite boring, therefore this method handles it
* by wrapping it into a {@link RuntimeException}
*
* @param connection the connection from which a database signature must be created
* @return a new {@link DatabaseSignet}
*/
public static DatabaseSignet fromMetadata(Connection connection) {
try {
DatabaseMetaData databaseMetaData = connection.getMetaData();
return new DatabaseSignet(databaseMetaData.getDatabaseProductName(), databaseMetaData.getDatabaseMajorVersion(), databaseMetaData.getDatabaseMinorVersion());
} catch (SQLException e) {
throw Exceptions.asRuntimeException(e);
}
}
public static Comparator<DatabaseSignet> COMPARATOR = Comparator
.comparing(DatabaseSignet::getProductName)
.thenComparingInt(DatabaseSignet::getMajorVersion)
.thenComparingInt(DatabaseSignet::getMinorVersion);
private final String productName;
private final int majorVersion;
private final int minorVersion;
/**
* Constructor with mandatory elements.
* See {@link #fromMetadata(Connection)} to build one for a database.
*
* @param productName database product name, must be strictly equals to the one of database metadata, else detection algorithm will fail
* @param majorVersion database product major version, as the one given by database metadata
* @param minorVersion database product minor version, as the one given by database metadata
* @see #fromMetadata(Connection)
*/
public DatabaseSignet(String productName, int majorVersion, int minorVersion) {
this.productName = productName;
this.majorVersion = majorVersion;
this.minorVersion = minorVersion;
}
public String getProductName() {
return productName;
}
public int getMajorVersion() {
return majorVersion;
}
public int getMinorVersion() {
return minorVersion;
}
/**
* Implemented as "product name X.Y". To be used for debug or simple printing.
*
* @return "product name X.Y"
*/
@Override
public String toString() {
return productName + " " + majorVersion + "." + minorVersion;
}
}
}